Skip to content

feat(feature-flags): Evaluate feature flags locally with a polling runtime client - #765

Merged
stanleyphu merged 6 commits into
mainfrom
feat/feature-flags-runtime-sync-client
Nov 5, 2026
Merged

stanleyphu merged 6 commits into
mainfrom
feat/feature-flags-runtime-sync-client

Conversation

@stanleyphu

@stanleyphu stanleyphu commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor

Stack. This is #762, split into four PRs for review. Merge them in order; after each merge I'll rebase the next one onto main.

  1. chore(feature-flags): Parse and evaluate flag configurations locally #764 Configuration and evaluation
  2. feat(feature-flags): Evaluate feature flags locally with a polling runtime client #765 Sync runtime client ← this PR
  3. feat(feature-flags): Add an async feature flags runtime client #766 Async runtime client
  4. chore(feature-flags): Keep the runtime clients safe across fork() #767 Fork safety

Description

FeatureFlagsRuntimeClient polls GET /sdk/feature-flags?payload_version=2 on a daemon thread and keeps the configuration from #764 in memory. It evaluates flags locally, with no request per check.

flags = workos.create_feature_flags_runtime_client()
flags.start()
flags.wait_until_ready(timeout=5)
flags.is_enabled("new-checkout", {"user": "user_123", "organization": "org_123"})
  • Failures.
    • A failed poll keeps the last good configuration and backs off exponentially.
    • A 429's Retry-After is honored up to 60 seconds, by retries inside a refresh and before the next poll.
    • Transient failures are retried within one refresh, up to the client's max_retries and within request_timeout.
    • A 401 stops polling for good.
  • Time budget. Each refresh has a total budget. The transport streams the response and gives every network wait only the time left, so a refresh ends within request_timeout plus at most one read of a body that stalls. A copy of those timeouts still gets the time left, never None, in case a custom transport or a later httpcore copies them instead of reading each phase as it starts.
  • Also included: bootstrap, on_change, on_error and on_status listeners, status, get_flag(), and a manual refresh() that works without start().
  • Listeners run after the refresh lock is released, one batch at a time and in refresh order, so a listener can call refresh() itself. An async def listener raises TypeError when it's registered, since nothing on the sync client would await it.
  • close() stops polling, and nothing a refresh still in flight does changes the configuration or status after it returns. Every caller waits for the poller, including concurrent ones.
  • Change events follow .NET: one on_change per refresh that installs a new configuration, including the first load.
  • v1 payloads fail the refresh and keep the previous configuration.

Where to look, in order:

  1. _core.py: state, evaluation and listeners.
  2. _sync.py: _poll_forever, _refresh_locked and _drain.
  3. _transport.py: the time budget.
  4. _policy.py: backoff and retry decisions.

Not in this PR: fork handling comes in #767. Until then, the README says to create the runtime client in each worker after forking. Merge #765 through #767 before the next release PR, so the first release includes fork handling.

How I checked it:

  • I ran it in our feature flags runtime lab, side by side with the Node SDK, against the same fixture responses. All 80 side-by-side checks pass: 40 for Python, 40 for Node. They cover:

    • rule order, contexts, disabled flags, unknown rules and non-boolean values;
    • bootstrap;
    • 401, 403 and 500 responses;
    • slow responses and v1 responses.

    The lab's asyncio checks need feat(feature-flags): Add an async feature flags runtime client #766.

  • test_transport.py sends the poll to a real local HTTP server. That includes one server that stalls before answering and one that drips the body a byte at a time. In both cases the refresh ends within its budget. Its other tests copy the timeouts every common way on a fake clock and check each phase gets the time left; on the code before that change they got None.

  • nox -s ci passes locally: 3,693 tests.

Documentation

Does this require changes to the WorkOS Docs? E.g. the API Reference or code snippets need updates.

[x] Yes

The feature flags SDK docs describe the runtime client as Node only. A docs PR adding Python, covering both clients, is still to do.

🤖 Generated with Claude Code

@greptile-apps

greptile-apps Bot commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor

RetriggerView in GreptileConfidence Score: 5/5

[Medium impact] The PR appears safe to merge in the documented stack order.

Summary

Adds FeatureFlagsRuntimeClient for background polling and local flag checks.

  • Applications can check feature flags from a saved local configuration.
  • The runtime client keeps its flags current and reports refresh results.
  • Refreshes retry within a time budget and honor server retry delays.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  A[start or refresh] --> B[Fetch configuration within time budget]
  B --> C{Result}
  C -->|Valid payload| D[Install complete snapshot]
  C -->|Temporary failure| E[Keep last good snapshot and back off]
  C -->|401| F[Stop background polling]
  D --> G[Deliver queued listeners after unlocking]
  D --> H[Evaluate flags locally]
  E --> H
  F --> H
Loading

Reviews (6) · Last reviewed commit: "fix(feature-flags): Reject async listene..." · Reviewed by Greptile

@stanleyphu

Copy link
Copy Markdown
Contributor Author

@greptileai review

1 similar comment
@stanleyphu

Copy link
Copy Markdown
Contributor Author

@greptileai review

@gjtorikian gjtorikian left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The concurrency design here is solid, and the real-server stall and drip tests are great. One change requested before merge, on the deadline timeout dict in _transport.py: it should keep a bounded fallback if anything copies it rather than reading it lazily. Details inline.

"""

def __init__(self, deadline: float) -> None:
super().__init__({phase: None for phase in _PHASES})

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_DeadlineTimeouts seeds every phase with None and only overrides get and __getitem__. This works today because httpx2 passes a caller-supplied timeout extension through build_request untouched, and httpcore reads each phase lazily with timeouts.get(...) at the start of that phase (checked against httpx2 2.13.0 and httpcore 1.0.9).

Any consumer that copies the dict, such as a custom transport or a future httpcore change, would see None for every phase. A stalled server would then hang the poller silently: flags go stale, nothing is logged, and close() gives up after its join timeout.

Could you seed each phase with the remaining budget instead of None, and override items, __iter__ and copy to return remaining() as well, so a copying consumer still gets a bounded wait? A short comment naming the httpcore call sites this relies on (connection_pool, connection, http11, http2, http_proxy) would also help whoever does the next httpx2 upgrade.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call, fixed in 1980d27. Each phase now starts with the full budget, and copying the dict any of the usual ways (dict(), {**t}, update(), copy(), copy.copy(), items(), values()) gets the time left instead of None.

A couple small differences from what you suggested:

  • iter yields keys so it can't return remaining(), but overriding it is what forces dict()/{**t}/update() through getitem instead of copying the stored values
  • copy() returns a new _DeadlineTimeouts with the same deadline, so the copy keeps counting down

Also added the httpcore call sites to the docstring (plus socks_proxy, which reads connect too), and added tests that copy the dict each way on a fake clock. Before the fix they got None for every phase.

@stanleyphu

Copy link
Copy Markdown
Contributor Author

@greptileai review

@stanleyphu
stanleyphu requested a review from gjtorikian November 4, 2026 19:49
Base automatically changed from feat/feature-flags-runtime-evaluation to main November 4, 2026 23:09
stanleyphu and others added 6 commits November 4, 2026 15:36
…ntime client

FeatureFlagsRuntimeClient polls GET /sdk/feature-flags?payload_version=2
on a daemon thread, keeps the configuration in memory and evaluates flags
locally from it. A failed poll keeps the last good configuration and backs
off, transient failures are retried within request_timeout, and a 401 stops
polling. It supports bootstrap, change, error and status listeners, status
and a manual refresh().

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A retry inside a refresh waited for whatever Retry-After the server sent,
uncapped and possibly negative, while the wait before the next poll was
capped at 60 seconds. Both now use the same capped value, as Node does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
close() only joins the background thread, so a manual refresh() that
passed its closed check just before close() could still install a new
configuration after close() returned, without a change notification. The
check and the snapshot update now happen under the lock close() takes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…stop

A second close() returned as soon as it saw the client was closed, while
the first was still waiting for an in-flight poll, so the second caller
could close the WorkOSClient under it. Every caller now waits for the
poller thread, as the async client does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The poll's timeout dict stored None for every phase and only answered
get() and [] with the time left. That holds while httpcore reads each
phase lazily, but a custom transport or a later httpcore that copied the
dict would have read None, which means no timeout, so a stalled server
could hang the poller. The stored values now start at the whole budget,
and dict(), {**...}, update(), copy(), copy.copy(), items() and values()
all give the time left.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The sync client never awaits what a listener returns, so an async
function registered as a listener silently never ran. Raise TypeError
at registration instead, including for on_change, on_error and
on_status. The check lives in the sync client because the async client
accepts coroutine listeners.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@stanleyphu
stanleyphu force-pushed the feat/feature-flags-runtime-sync-client branch from 1980d27 to 3437e7d Compare November 4, 2026 23:43
@stanleyphu

Copy link
Copy Markdown
Contributor Author

@greptileai review

@stanleyphu
stanleyphu merged commit e641a53 into main Nov 5, 2026
14 checks passed
@stanleyphu
stanleyphu deleted the feat/feature-flags-runtime-sync-client branch November 5, 2026 00:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

3 participants